Zanim zajmiemy się tematem obrony przed hakerami, postaramy się ugryźć temat od trochę łatwiejszej strony – jakie są dobre praktyki przy pisaniu aplikacji webowych?
Oczywiście nie poruszymy tu wszystkich możliwych kwestii. Bezpieczeństwo aplikacji jest tak szerokim tematem, że jeden moduł, a tym bardziej submoduł, to zdecydowanie za mało, aby przedstawić Ci wszystko. Niemniej jednak postaramy się zaprezentować najbardziej podstawowe zagadnienia, które powinny wystarczyć, by obronić się przynajmniej przed wścibskimi użytkownikami.
Ukrywaj wrażliwe dane
Może wydawać Ci się to oczywiste i nie do końca rozumiesz, dlaczego musimy o tym mówić. Z prostego powodu. Hasła do naszych kont są na tyle istotne, że powinniśmy ich strzec tak mocno, jak to możliwe. Czy jednak faktycznie zawsze to robiliśmy?
Wróćmy na chwilę do naszego przykładu z festiwalem muzycznym. W pewnym momencie łączyliśmy się ze zdalną bazą danych i wpisywaliśmy w Connection String login oraz hasło użytkownika, aby umożliwić komunikację. Dane te były zapisane w kodzie. Czy aby na pewno jest to bezpieczne? W teorii serwer to miejsce, do którego zwykły użytkownik nie ma dostępu, nie zapomnij jednak, że cały nasz projekt wrzucaliśmy na GitHub. Tam każdy może podejrzeć nie tylko kod klienta, ale także serwera, czyli również... nasze hasło.
Oczywiście możesz powiedzieć, że to tylko aplikacja testowa, więc nie ma to znaczenia. Jednak, czy aby na pewno nie zdarzyło Ci się już użyć gdzieś tego hasła? Hakerzy dobrze wiedzą, że większość internautów korzysta często z dokładnie takiej samej kombinacji znaków. Możesz więc narazić się na to, że ktoś będzie próbował wykorzystać znalezione dane logowania np. na Facebooku albo poczcie internetowej. Możliwy jest jeszcze gorszy scenariusz, bo co jeśli nie zmienisz hasła przed wypuszczeniem aplikacji w świat? Nawet jeśli uczynisz od tego momentu repo prywatnym, nie masz gwarancji, że ktoś już wcześniej nie zapisał tych danych. Takie zaniedbanie mogłoby Cię sporo kosztować.
Może wyjściem jest po prostu ukrywanie repo? W końcu GitHub zezwala na darmową opcję private. Problem jest jednak taki, że często chcemy nasz kod pokazywać, np. aby rekruter mógł zobaczyć, jak sprawnie poruszamy się w Node.js czy MongoDB. Jak sobie z tym poradzić?
Odpowiedź jest prosta. Kod może być przechowywany na otwartym repo, jednak bez udostępniania hasła. W takich przypadkach najlepiej wrzucić aplikację na Heroku, bo w ten sposób rekruter zobaczy ją w akcji bez potrzeby kompilacji, a trzymanie hasła na GitHubie nie będzie już konieczne.
Możesz jednak powiedzieć, że przecież Heroku też opiera się na repo. Wydaje się więc, że musielibyśmy jeden kod wrzucać na ten serwis, a inny do GitHuba. Nic bardziej mylnego, bowiem Heroku oferuje mechanizm zmiennych środowiskowych. Jeśli w naszym kodzie zapiszemy, że hasło ma być brane np. z env.dbpass, a następnie już w samej konfiguracji Heroku nadamy tej zmiennej odpowiednią wartość, to przy kompilacji, właśnie ona będzie wykorzystywana. Tym samym w kodzie widzimy tylko nazwę zmiennej, ale już na Heroku połączenie z bazą będzie zrealizowane poprawnie, przy wykorzystaniu prawdziwego hasła czy loginu.
Oczywiście, mechanizm zmiennych środowiskowych, możemy też wykorzystać do innych haseł czy informacji, a nie tylko dla danych logowania do bazy.
Uwaga!
Nie zapomnij jednak, że trzymanie kodu serwera na repo, nawet bez haseł, może być ryzykowne. Jeśli Twój skrypt ma jakieś luki, to haker będzie mógł je łatwo wykryć, a następnie zaatakować opublikowaną na Heroku aplikację, tak aby np. pobrać za jej pomocą dane z bazy.
Pamiętaj, że publikując kod na publicznym repo, wystawiasz go na takie niebezpieczeństwo. Jeśli to tylko aplikacja testowa, do nauki, nie musisz się niczego obawiać. W przypadku poważniejszych aplikacji miej to na uwadze.
Dobre hasło
Ukrywanie hasła nic nie da, jeśli jest ono po prostu słabe. Pamiętaj, że hakerzy często operują wydajnym sprzętem, na którym aplikacje do łamania szyfrów działają niezwykle sprawnie.
Jak możemy się przed tym bronić? Po prostu staraj się, żeby Twoje hasło było w miarę długie, nieprzewidywalne oraz zawierało różnorodne znaki. To oczywiście bardzo ogólna odpowiedź. Jest to jednak minimum, którego powinniśmy się trzymać. Ciężko stworzyć dokładną listę wymagań, gdyż te często się zmieniają, np. do niedawna bardzo popularną praktyką było wymuszanie na użytkownikach cyklicznej zmiany hasła. Tymczasem firma Microsoft twierdzi, w oparciu o swoje doświadczenia, że takie podejście czyni więcej złego niż dobrego. Użytkownicy z lenistwa i tak wybierają prawie identyczne hasła, zmieniając tylko jeden z ostatnich znaków, dlatego też np. w swoich nowych wytycznych dla Office 365 technologiczny gigant zaleca... odejście od tego pomysłu.
Ćwiczenie
W ramach praktyki spróbuj wykorzystać nową wiedzę. Wróć do naszej festiwalowej aplikacji i postaraj się ukryć dane logowania do bazy w zmiennych konfiguracyjnych Heroku. Możesz ustawić je w konsoli albo w panelu administracyjnym, wystarczy wejść w zakładkę opcji swojej aplikacji. Dokładną instrukcję znajdziesz pod tym linkiem.
Do zmiennych mamy dostęp w obiekcie process.env, więc jeśli dodasz nową np. o nazwie test, to Twoja aplikacja będzie miała do niej wgląd pod process.env.test.
W efekcie, przy połączeniu z bazą zdalną, aplikacja powinna korzystać ze zmiennych konfiguracyjnych i wciąż działać poprawnie. Dzięki temu na naszym repo nie będziemy musieli już przechowywać tak wrażliwych danych.
Nie ufaj klientowi
Ten podrozdział powinien nazywać się raczej – nie ufaj temu, co dostarcza Ci klient.
Przypuśćmy, że tworzymy panel administracyjny jakiejś aplikacji i zajmujemy się właśnie implementacją formularza do dodawania nowych użytkowników. Nie jest to nic wielkiego, załóżmy, że mamy dosłownie trzy pola – login, password i role. Nas najbardziej interesuje to ostatnie, które pozwala na wybranie opcji user albo admin. Nadana rola definiuje oczywiście poziom uprawnień, przy czym z panelu administracyjnego mogą korzystać zarówno użytkownicy o statusie admin, jak i user. Obie grupy mają prawo dodawać innych za pomocą naszego formularza, z tym zastrzeżeniem, że userzy mogą dołączać tylko tych, którzy mają tę samą rolę (user), a admini mogą dodawać również administratorów.
Nie będziemy wnikać, jak dokładnie zbudowany jest ten formularz. Załóżmy jednak, że korzystając z danych otrzymanych od serwera, klient zawsze wie, jaką rolę ma aktualnie zalogowany użytkownik. Na bazie tych uprawnień pozwala na wybranie jednej z ról (jeśli jesteśmy administratorem) albo z góry ustawia formularz jako user. Do tego mamy bardzo dokładną walidację i np. za krótkie hasło albo za długi login nie pozwala nawet na wysłanie formularza.
Skoro zakładamy, że klient połączy się z serwerem tylko wtedy, kiedy dane są dobre, to wydaje się, że ponowna walidacja na serwerze jest zbędna. Nasz endpoint mógłby więc wyglądać tak:
app.post('/user', (req, res) => {
try {
const { login, password, role } = req.body;
const user = new User({ login, password, role });
await user.save();
res.status(201).json({ message: 'OK' });
}
catch(err) {
res.status(500).json({ message: err })
}
});
Czy to aby na pewno dobry pomysł?
Pamiętaj, że jeśli serwer API jest dostępny dla naszego klienta, to równie dobrze można się z nim połączyć z innego. W takiej sytuacji ktoś mógłby, nawet za pomocą Postmana, wysłać następujący request, oczywiście z pominięciem naszej aplikacji klienta:
POST example.com/api/user
{
"login": "Tooooo long login",
"password": "123",
"role": "admin"
}
Co by to dało atakującemu? Stworzyłby nowe konto, które miałoby uprawnienia admina, nawet jeśli sam nie miał nawet statusu użytkownika. Przy okazji złamałby jeszcze zasady co do trudności hasła i maksymalnej długości loginu, bo w końcu serwer sam już tego nie weryfikuje. Co więcej, backend nie sprawdza nawet, czy ktoś jest zalogowany.
Skąd atakujący wiedziałby jak ma skonstruować request? Wystarczy, że chociaż przez chwilę był użytkownikiem naszej aplikacji i zajrzał do zakładki "Network" w narzędziach developerskich.
Jak możemy się przed tym ustrzec? Najprostsze jest blokowanie połączenia z zewnątrz.
Dotychczas używaliśmy middleware cors przeważnie po to, by pozwalać na wszelką łączność z zewnątrz, ale możemy go wykorzystać także w przeciwnej sytuacji. Aby ograniczyć połączenia AJAX tylko do tych wysyłanych z naszej witryny, wystarczy wywołać cors() z odpowiednią opcją:
app.use(cors({
origin: 'http://example.com'
}));
Możemy również zezwolić na połączenia np. z dwóch albo trzech wspieranych adresów.
app.use(cors({
origin: function(origin, callback){
if(!origin) return callback(null, true);
if(allowedOrigins.indexOf(origin) === -1){
const msg = 'The CORS policy for this site does not allow external access...';
return callback(new Error(msg), false);
}
return callback(null, true);
}
}));
W taki sposób możemy zablokować kilku domorosłych hakerów, warto jednak pamiętać, że niestety nie jest to rozwiązanie idealne. Wciąż istnieje możliwość skonstruowania requestu, chociażby za pomocą funkcji curl w konsoli i stworzenia zapytania z ręcznie ustawionym origin. Tym samym bardziej wprawny atakujący może udawać, że połączenie pochodzi z tej samej witryny...
cors jest więc jakimś buforem bezpieczeństwa, ale jednak da się go obejść. Rozwiązanie może być w takiej sytuacji tylko jedno – musimy sprawdzać również na serwerze, kim jest użytkownik i jakie dane wprowadza. Tutaj przechodzimy do kolejnej dobrej praktyki.
Zawsze waliduj dane również na serwerze
Przed chwilą pisaliśmy, że nie możemy ufać walidacji po stronie klienta, ponieważ użytkownik może obejść ją wysyłając requesty z innego źródła. Wiemy też, że cors nie da nam stuprocentowej gwarancji, że wszystkie takie próby będą zablokowane. Niemniej jednak, nawet gdyby było to możliwe i mielibyśmy pewność, że serwer przyjmowałby dane tylko od naszego własnego klienta, to i tak nie moglibyśmy czuć się bezpiecznie. Dlaczego?
Na tym etapie kursu wiesz już jak duże zmiany możemy wprowadzać, korzystając z narzędzi developerskich i podglądu strony, na której aktualnie jesteśmy. Co z tego, że pole tekstowe ma atrybut required? Możemy wejść do inspektora i to zmienić. Cóż, że jakiś <select> będzie dla nas zablokowany (disabled)? Jesteśmy w stanie wyłączyć ten atrybut w konsoli. Problemem nie jest nawet walidacja w JSie, bo ten kod też zmodyfikujemy "w locie". Ostatecznie możemy też po prostu wyłączyć obsługę JavaScriptu w przeglądarce.
Spójrz tylko na poniższy przykład:
Mamy tutaj formularz, który przypomina ten wspomniany już w submodule. Zauważ, że w HTML-u ustaliliśmy, iż pola "E-mail" i "Hasło" są wymagane. Dodatkowo aktualnie zalogowany użytkownik nie ma prawa wskazać innej roli niż user. Pamiętamy bowiem, że w założeniu funkcję admin może wybrać tylko osoba, która sama jest administratorem.
Okazuje się jednak, że taka walidacja jest dziecinnie łatwa do ominięcia. Wystarczyło wejść do inspektora, zmienić kilka elementów i nagle formularz może zostać wysłany mimo kompletnego pogwałcenia warunków walidacji. Zauważ, że w powyższym przykładzie bez problemu wysłaliśmy go bez wypełnienia pola "E-mail" czy "Hasło", a do tego, mimo braku uprawnień, wybraliśmy rolę nowego użytkownika jako admin. Jak jest z tego wniosek? Nie ufaj walidacji po stronie klienta.
Nieważne jak bardzo wyrafinowana będzie taka kontrola, zawsze znajdzie się ktoś, kto łatwo ją wyłączy. Nigdy nie możemy wierzyć, że to, co dostajemy od klienta, faktycznie jest poprawne. Frontendową walidację traktuj raczej jako usprawnienie UX (User Experience), które ma być pomocą dla użytkownika. W końcu dzięki takiej funkcjonalności może od razu przekonać się, co robi nie tak, zamiast czekać na reakcję serwera na wysyłane dane. Pod względem bezpieczeństwa o wiele ważniejsza jest walidacja po stronie serwera. Użytkownik nie jest w stanie jej wyłączyć i co najwyżej może szukać luk, aby ją jakoś oszukać.
Podsumowując – walidacja po stronie klienta to tylko usprawnienie UX, zawsze weryfikuj dane również drugi raz, po stronie serwera.
Jak mógłby wyglądać nasz wcześniejszy przykład endpointu po modyfikacjach?
app.post('/user', (req, res) => {
try {
const { login, password, role } = req.body;
if(!login || !password || !role) throw new Error('Invalid data');
else if(!userLogged())
else {
const user = new User({ login, password, role });
await user.save();
res.status(201).json({ message: 'OK' });
}
}
catch(err) {
res.status(500).json({ message: err })
}
});
Zapewne udało Ci się zauważyć, że w samym endpoincie nie sprawdzamy poprawności danych, czyli np. czy login nie jest za długi. To dlatego, że akurat takie reguły walidacji można wprowadzić w samym schemacie modelu. Robiliśmy to już w poprzednich modułach.
Obsługuj potencjalne błędy
Bardzo często pracujemy przy użyciu zewnętrznych bibliotek, a te mogą mieć różne systemy powiadamiania o błędach. Niektóre "wyrzucają" po prostu Error, inne wypisują dokładne komunikaty (np. Couldn't connect to cluster...), a jeszcze inne same pozwalają nam zadecydować, jak mają zachowywać się w przypadku problemu. Różne nieprawidłowości mogą sygnalizować również niektóre z metod wbudowanych w JS-a. Wniosek jest jednak jeden – w przypadku błędu, użytkownik może zobaczyć komunikat, który niekoniecznie chcielibyśmy mu pokazywać.
Idea, aby informować użytkownika o błędzie oczywiście nie jest zła, niemniej jednak powinniśmy to robić w sposób dla niego zrozumiały. Często komunikaty są bardzo długie i enigmatyczne. Na przykład błąd #23535, Error: Couldn\'t connect to cluster. Problem on line 5, cluster-connect.js. [ERR_WRONG_USERNAME] dla nas byłby bardzo pomocy, bo dość dokładnie wskazywałby miejsce problemu, jednak czy użytkownika naprawdę to interesuje? Dla niego lepsza byłaby informacja w stylu Couldn't connect to DB... Try again – krótsza, ale nie zawierająca zbędnych technicznych szczegółów.
Inna sprawa, że komunikaty wyrzucane przez skrypty mogą też czasem ujawniać wrażliwe dane, na przykład przy łączeniu się z bazą danych: Err: DB connection error. Couldn't connect with [username]=JohnDoe and [password]=admin1. Taka informacja mogłaby pojawić się nawet po przypadkowym przeciążeniu serwera bazy danych, a naraziłby nas na ogromne ryzyko.
Podsumowując, nie chcemy, aby JS "wyrzucał" użytkownikowi domyślną treść błędów, ponieważ jest ona często niezrozumiała, a może nas narazić na wyciek wrażliwych danych.
Czy to oznacza, że musimy w ogóle zrezygnować z wyświetlania komunikatów użytkownikowi? Nie. Powinniśmy jednak wyłapywać błąd, który wskazuje nam dany skrypt i sami decydować, co pokażemy. Na przykład, gdy wykryjemy problem z połączeniem z bazą, to zamiast pozwolić JS-owi na wyrzucenie pełnego komunikatu, będziemy wypisywać coś sami. Taki efekt możemy osiągnąć przy użyciu bloku try ... catch, który jak zapewne pamiętasz, w przypadku wykrycia jakiegoś błędu, uruchamia kod umieszczony w catch (catch to z ang. łapać).
Podsumowując, w przypadku kodu, który może spowodować problem, powinniśmy wyłapywać potencjalne błędy, a następnie pokazywać zrozumiały dla użytkownika komunikat na ich temat. Oczywiście robimy to, o ile jest w ogóle sens o tym informować. Mogą zdarzyć się bowiem sytuacje, w których mimo problemów, aplikacja będzie działać normalnie, np. jeśli błąd pojawił się w module, który zapisuje statystki odwiedzin strony. Czy jego awaria powinna w ogóle interesować użytkownika? Raczej nie.
Z try ... catch już korzystaliśmy, ale dla przypomnienia przedstawiamy jeszcze jeden przykład.
connectToDB('JohnDoe', 'admin1');
Załóżmy, że connectToDB to funkcja, która stara się łączyć z bazą danych. W przypadku błędu oczywiście pokaże ona od razu jakiś komunikat i na razie nie mamy nad nim kontroli.
Gdybyśmy jednak skorzystali z bloku try ... catch, to ten błąd nie byłby od razu zwracany. Zamiast tego catch przyjmowałby go jako err, a my moglibyśmy decydować co z nim zrobić dalej – pokazać, zignorować, czy wyświetlić własny komunikat.
try {
connectToDB('JohnDoe', 'admin1');
} catch(err) {
console.error(err);
}
albo
try {
connectToDB('JohnDoe', 'admin1');
} catch(err) {
console.log('Couldn\'t connect to db...');
}
Przydatne komunikaty
Dokładne komunikaty o błędach mogą być też bardzo pożyteczne. Użytkownik ich nie potrzebuje, ale my, jako twórcy kodu, często chcemy korzystać z ich pomocy. W końcu może nas to bardzo szybko prowadzić do rozwiązania. Dlatego też całkowite ich ignorowanie i wypisywanie własnych prostych komunikatów nie zawsze jest dobre.
Optymalnym rozwiązaniem może być pokazywanie różnych komunikatów zależnie od tego, w jaki sposób uruchomiliśmy nasz kod, opierając się np. na jakiejś zmiennej z process.env.
try {
connectToDB('JohnDoe', 'admin1');
} catch(err) {
if(process.env.debug === true) console.log(err);
else console.log('Couldn\'t connect to db...');
}
W powyższym kodzie ustawiliśmy wartość true dla zmiennej debug. W takim przypadku, jeśli pojawi się błąd, dostaniemy jego dokładną treść. Gdyby jednak skrypt był uruchamiany standardowo, bez debug ustawionego na true, to użytkownik przy błędzie zobaczyłby znacznie przyjaźniejszy komunikat.
Dzięki temu możemy w razie problemu uruchamiać nasze aplikacje w taki sposób, aby być informowanym na bieżąco o szczegółowych błędach, a sam użytkownik korzystający z "normalnej" wersji, wciąż widziałby tylko to, co dla niego zaplanowaliśmy.
Dokładnie filtruj wprowadzane dane
Często sprawdzenie samego typu danych nie wystarcza, bo np. string może być zarówno zwykłym tekstem (Lorem Ipsum), kodem HTML (<p>Lorem Ipsum</p>), jak i wyrażeniem regularnym (/^[A-Z]{3}$/)). Weryfikowanie wyłącznie typu może więc przynieść opłakane skutki.
Aby lepiej Ci to zobrazować, postaramy się to przedstawić na przykładzie.
Powiedzmy, że na swojej stronie masz system komentarzy. Co istotne, chcesz aby były one prostymi wiadomościami tekstowymi, ewentualnie z pogrubieniem lub pochyleniem tekstu. Do wpisywania danych korzystasz z formularza wraz z elementem <textarea>. Sam formularz jest jednak jeszcze obsługiwany przez jakiś dodatkowy plugin, który pozwala właśnie na ustawienie pogrubienia czy też pochylenia. Po wysłaniu tego formularza Twój kod na serwerze odbiera treść komentarza, sprawdza, czy jest stringiem i jeśli tak, to dodaje go do bazy danych.
Kod na serwerze mógłby wyglądać mniej więcej tak:
app.post('/comment', (req, res) => {
const { text } = req.body;
try {
if(!text || !text.length) throw new Error('"text" param is invalid!');
else {
const comment = new Comment();
comment.text = text;
await comment.save();
res.status(201).json({ message: 'OK' });
}
} catch(err) {
res.status(500).json({ message: 'Something went wrong...' });
}
});
Nie sprawdzamy tutaj typu bezpośrednio w endpoincie, bo jak zapewne pamiętasz, jest on ustalany w schemacie modelu. Jeśli więc typ danych byłby inny niż string, to Mongoose i tak zwróciłby nam błąd i zwyczajnie nie pozwoliłby na dodanie nowego dokumentu.
Oprócz tego jeden z komponentów na stronie pobiera te dane i wyświetla w następujący sposób:
const comments = ({ comments }) => (
<ul>{comments.map(comment => <li key={comment.id}>{comment.text}</li>)}</ul>
);
Nic specjalnego, ma on po prostu pokazywać nasze komentarze w formie listy z elementami li. Oczywiście zakładamy, że w comment.text mogą być wykorzystywane proste tagi HTML (do pogrubiania albo pochylania tekstu). Dlatego też powinniśmy "powiedzieć" Reactowi, aby traktował atrybut text jako możliwy kod HTML.
const comments = ({ comments }) => (
<ul>{comments.map(comment => <li key={comment.id} dangerouslySetInnerHTML={{ __html: comment.text }}></li>)}</ul>
);
I teraz, dopóki wszyscy użytkownicy będą poprawnie korzystali z naszego formularza, wszystko będzie dobrze. W naszej bazie będą zapisywać się dokumenty np. o takiej treści:
- <strong>Lorem</strong> Ipsum
- <em>Lorem</em> Ipsum
- Lorem Ipsum
A gdy będziemy je pobierać i pokazywać w HTML-u, to też zobaczymy poprawne komentarze:
- Lorem Ipsum
- Lorem Ipsum
- Lorem Ipsum
Takie było założenie. Teraz jednak wyobraź sobie, że jakiś złośliwy użytkownik specjalnie wyłączył plugin do obsługi textarea i sam ręcznie, w inspektorze, wpisał taką treść:
<h1>Haha, my comment is the most important!</h1>
Gdyby następnie wysłał formularz do serwera, jak zareaguje na to nasz endpoint? Sprawdzi, czy text istnieje, czy nie jest pusty, oraz czy to w ogóle string. Mongoose uzna, że tak, więc zapisze go do bazy danych. Przez to, gdybyśmy odwiedzili po takim ataku naszą stronę, okazałoby się, że jeden z komentarzy jest nienaturalnie duży.
- Lorem Ipsum
- Lorem Ipsum
- Lorem Ipsum
-
Haha, my comment is the most important!
Brak dokładnej walidacji i przefiltrowania otrzymanego stringu spowodował łatwą do wykorzystania lukę, która oczywiście daje znacznie większe pole do popisu. Wyobraź sobie, że atakujący dodał taki komentarz:
<div style="background: black; width: 100%; height: 100%; position: fixed; top: 0; left: 0; color: #fff">This website has been hacked...</div>
Znowu serwer przyjmie, że to string i zapisze go do bazy danych. Tymczasem, kiedy nasz klient wyrenderuje taki "komentarz", to div, który się pojawi, zakryje całą stronę...
Mamy tu więc kilka wniosków. Po pierwsze słowo dangerously w nazwie funkcji dangerouslySetInnerHTML nie wzięło się znikąd. Renderując HTML pobrany z zewnątrz, powinniśmy być w stu procentach pewni, co do jego poprawności.
Po drugie, nawet mały błąd, czy luka w walidacji może pozwolić atakującemu na spektakularnie wyglądające "popsucie" naszej aplikacji. Zauważ, że mimo całkiem sensownej kontroli na serwerze, a więc sprawdzenia, czy dane zostały otrzymane oraz czy ich typ jest dobry, nasz bariera ochronna okazała się za słaba. To pokazuje, jak bardzo uważni musimy być podczas pisania kodu.
Po trzecie, już w formie podsumowania – przy walidacji danych sprawdzaj nie tylko typ, ale też jak ta wartość jest zbudowana. Jeśli mamy otrzymać np. nazwy kolorów po angielsku i mają być one pisane małą literą, to sprawdzajmy, czy wprowadzany tekst faktycznie spełnia te wymagania. Jeśli wiemy, że ma to być jedna z kilku ról, np. admin albo user, to sprawdźmy, czy to faktycznie jedna z nich. Musimy być jak najdokładniejsi. Przy tworzeniu walidacji, zakładaj z góry wszystkie możliwe złe scenariusze. Spodziewaj się, że atakujący postara się znaleźć wszelkie luki i nieścisłości.
Dobrze, ale jak w takim razie poradzić sobie z naszym problemem? Tego typu zagadnienia rozwiązujemy zazwyczaj przy użyciu wyrażeń regularnych.
W naszym przypadku mogłoby to wyglądać następująco:
app.post('/comment', (req, res) => {
const { text } = req.body;
try {
const pattern = new RegExp(/(<\s*(strong|em)*>(([A-z]|\s)*)<\s*\/\s*(strong|em)>)|(([A-z]|\s|\.)*)/, 'g');
const textMatched = text.match(pattern).join('');
if(textMatched.length < text.length) throw new Error('Invalid characters...');
...
Jak to działa?
const pattern = new RegExp(/(<\s*(strong|em)*>(([A-z]|\s)*)<\s*\/\s*(strong|em)>)|(([A-z]|\s|\.)*)/, 'g');
Najpierw przygotowujemy odpowiedni pattern. Na pierwszy rzut oka może wydawać się on bardzo skomplikowany, ale jego idea jest prosta. Powinien dopasować się do tagów <strong></strong> lub <em></em>, o ile wewnątrz nich znajduje się tylko tekst albo spacje ((([A-z]|\s)*)). Odpowiada za to cała pierwsza część – (<\s*(strong|em)*>(([A-z]|\s)*)<\s*\/\s*(strong|em)>). Oprócz tego pattern dopasuje się również do każdego pozostałego tekstu, spacji lub kropki (w końcu komentarz może być zbudowany z kilku zdań). Pamiętaj, że < czy > to już nie litery, więc ta część nie bierze pod uwagę żadnych tagów, odpowiada za to fragment (([A-z]|\s|\.)*). Zatem pattern dopasuje się do tagów <strong> i <em> albo zwykłego tekstu.
const textMatched = text.match(pattern).join('');
W kolejnej linijce staramy się sprawdzić, ile tekstu w text będzie zgodne z naszym patternem. W teorii powinniśmy liczyć na to, że od klienta otrzymamy właściwe dane, więc tak naprawdę do założeń powinien pasować cały tekst.
if(textMatched.length < text.length) throw new Error('Invalid characters...');
Na końcu sprawdzamy więc, czy text początkowy jest zgodny z tym, co otrzymaliśmy. Jak mówiliśmy już wcześniej, jeśli text był poprawny, to w teorii textMatched powinien być dokładnie tak sam, a więc obie dane powinny mieć identyczną długość. Jeśli nie mają, świadczy to o tym, że część tekstu musiała nie pasować do naszego patternu, więc ktoś starał się wprowadzić do naszego systemu nieprawidłowe dane. Stąd też zwracamy wtedy adekwatny komunikat.
Na końcu krótka symulacja.
Jeśli text byłby równy <strong>Lorem</strong> Ipsum, to text.match(pattern) zwróciłoby nam następującą zawartość:
['<strong>Lorem</strong>', ' ', 'Ipsum'];
Udałoby się bowiem znaleźć trzy pasujące do patternu elementy. Po ich złączeniu (metoda join) otrzymalibyśmy:
'<strong>Lorem</strong> Ipsum'
Porównując nowy "dopasowany" do patternu tekst ze starym, dowiedzielibyśmy się, że ich długość jest taka sama, a więc nie było w oryginale elementu niepasującego.
Gdyby jednak tekst startowy wyglądał np. tak <strong>Lorem</strong> <h1>Test</h1>Ipsum, to po próbie dopasowania w textMatched otrzymalibyśmy:
'<strong>Lorem</strong> h1Testh1Ipsum'
Metoda match przepuściłaby same nazwy tagów (w końcu to zwykły tekst), ale to nie problem, bowiem nie dopasowałaby ani < ani >. Zatem textMatched byłby trochę inny od text (krótszy) i nasz serwer wyrzuciłby błąd.
W podobny sposób moglibyśmy testować również kompletnie inne przypadki, np. czy otrzymaliśmy od klienta tekst w formacie imię nazwisko. Co ciekawe, walidację z użyciem wyrażeń regularnych można "przykleić" również do samych modeli Mongoose. Wspominaliśmy o tym w poprzednich modułach. Dzięki temu nie musielibyśmy przeprowadzać tego procesu w samym endpoincie, bo mielibyśmy pewność, że Mongoose zrobi to za nas, przy próbie zapisania dokumentu do bazy.
Niebezpieczny HTML
Warto dodać, że najczęściej staramy się w ogóle nie zapisywać kodu HTML do bazy danych, a tylko tekst. Dlatego też samą treść najczęściej zwyczajnie się escape'uje przed dodaniem do bazy. Proces ten polega na podmianie cudzysłów, apostrofów oraz znaków &, < i > na odpowiadający im zapis kodowy.
Poniżej przedstawiamy przykładową funkcję, która mogłaby się tym zajmować:
function escape(html) {
return html.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
Co istotne, tak skonwertowany kod jest już w pełni bezpieczny. Zapewne pamiętasz bowiem, że taki zapis (np. <h1> </h1>) nie będzie odbierany przez przeglądarkę jako nagłówek <h1></h1>, tylko tekst <h1></h1>.
Korzystaj z Mongo-sanitize
Wyobraź sobie, że przygotowaliśmy następujący endpoint odpowiadający za logowanie:
app.post('/login', (req,res) => {
try {
const loggedUser = await User.findOne({ email: req.body.email, password: req.body.password });
if(loggedUser) res.send({ message: 'Logged!' });
else res.status(404).json({ message: 'Not found' });
}
catch(err) {
res.status(500).json({ message: 'Something went wrong' });
}
});
Czy wszystko jest tutaj w porządku? Wydaje się, że tak. Staramy się znaleźć takiego użytkownika, którego e-mail i hasło będzie zgodne z tym, co otrzymaliśmy od klienta. Jeśli istnieje, możemy rozpocząć nową sesję. Oczywiście w naszym przykładzie tylko zwracamy komunikat o sukcesie, a w normalnej sytuacji jednak przeprowadzalibyśmy dalej proces inicjacji sesji dla tego użytkownika.
No dobrze, ale wracając do tematu – wydaje się, że wszystko jest w porządku. No cóż... nie jest. Wyobraź sobie, że użytkownik pomija aplikację klienta i sam generuje odpowiedni request (wiemy, że to możliwe). Jako dane w body podaje coś takiego:
body: {
"email": { "$ne": "123" },
"password": { "$ne": "123" },
}
Jak zareagowałby na to nasz serwer? Skoro req.body.email to { "$ne": "123" }, a req.body.password to { "$ne": "123" }, w efekcie findOne otrzymałoby następujący warunek:
User.findOne({ email: { "$ne": "123" }, password: { "$ne": "123" } };
Pewnie domyślasz się, jakie miałoby to skutki – chcemy znaleźć jeden dokument, którego email i password są inne niż 123. Cóż... taki warunek spełnić bardzo łatwo. W końcu najprawdopodobniej żaden z użytkowników nie korzysta z hasła 123, a już tym bardziej nie posiada takiego e-maila. Zapewne Mongoose zwróciłaby nam tu pierwszego użytkownika z kolekcji.
Jaki mamy efekt? Atakujący nie musiał znać loginu ani hasła żadnego z użytkowników, a udało mu się zalogować na jednego z nich. Jeśli weźmiemy pod uwagę, że bardzo często pierwszym użytkownikiem jest administrator, mamy jeszcze większy problem. Atakujący otrzymałby bowiem dostęp do konta z bardzo dużymi uprawnieniami!
Użycie operatora $eq
Pierwszą linią oporu powinno być użycie operatora $eq.
const loggedUser = await User.findOne({ email: { $eq: req.body.email }, password: { $eq: req.body.password }});
Daje nam on gwarancję, że sprawdzimy, czy dany atrybut jest równy dokładnie temu, czego oczekujemy. Nie możemy jako wartości przekazać kolejnego operatora. Gdybyśmy więc nawet dalej pozwalali na wykonywanie requestów z takim body jak wcześniej, to otrzymany warunek
User.findOne({ email: { $gt: {"$ne": "123" }}, password: { $gt: { "$ne": "123" }} };
nie byłby dla nas groźny. $eq po prostu nie znalazłoby elementów, których atrybut byłby równy operatorowi.
Odpowiednio parsuj otrzymywane dane
Pamiętaj, że w ogóle nie powinniśmy doprowadzić do sytuacji, gdzie w Twoim req.body są treści, które mogą być niebezpieczne. Skoro oczekujemy na dwa stringi, to sprawdźmy, czy email i password faktycznie nimi są. A jeśli tak, to ustalmy również, czy nie mają jakichś niepożądanych znaków, np. $ czy klamerki. Podobną rzecz robiliśmy już w przypadku otrzymywania danych HTML. Dobrą praktyką jest ich escape'owanie. Warto byłoby odpowiednio parsować dane, które otrzymujemy.
Nie musisz jednak przygotowywać odpowiedniej funkcji od zera. Na rynku istnieje już bowiem paczka mongo-sanitize oferującą gotową funkcję sanitize. Jej zadaniem jest właśnie parsowanie wybranych danych. Działa ona w taki sposób, że pozbywa się niebezpiecznych znaków i zwraca nam wartość, której nie musimy się już obawiać i możemy spokojnie wykorzystać w dalszym kodzie.
Ćwiczenie
Spróbuj wykorzystać ten pakiet w naszym projekcie strony festiwalu.
Najpierw pobierz odpowiednią paczkę:
yarn add mongo-sanitize@1.0.1
Następnie spróbuj wykorzystać ją w jednym z endpointów typu POST. Instrukcję znajdziesz pod tym linkiem.
Uważaj na pakiety zależne
Idea korzystania w naszej pracy z zewnętrznych paczek towarzyszy nam od początku kursu. To dobra praktyka – skracamy czas produkcji, nie wymyślamy koła na nowo, a do tego niektóre z nich narzucają wykorzystywanie dobrych nawyków programistycznych. Niemniej jednak pamiętajmy, że nie każda paczka jest dobra. Istnieją takie, które są na rynku od lat, mają tylko pozytywne opinie, a do tego autorzy ciągle je wspierają i aktualizują. Trafiają się również takie, które są ich przeciwieństwem – mogą być słabo napisane, czy powodować błędy w niespodziewanych momentach. Jest jeszcze inna możliwość. Paczka sama w sobie nie jest zła, ale czas mija, nie była aktualizowana i w tej chwili stała się dziurawa i podatna na ataki. Pamiętaj, że zależności są często integralną częścią Twojej aplikacji i jeśli mają luki, Twój skrypt też nie jest w stu procentach bezpieczny.
Podążaj za sprawdzonymi rozwiązaniami
Staraj się korzystać z popularnych i cenionych paczek. Zwracaj uwagę na ilość wydań, pobrań oraz datę ostatniej aktualizacji. To bardzo ważne, aby dany pakiet był wciąż wspierany. Wtedy nawet w przypadku wykrycia luk, powinny być szybko załatane.
Unikaj paczek, które sam autor opisuje jako deprecated (przestarzałe). Nawet jeśli wydaje Ci się, że dana paczka jest idealna do Twoich potrzeb, to całkiem możliwe, że w wielu przypadkach nie będzie poprawnie współpracować z Twoim kodem. Do tego jest spora szansa, że ma jakieś luki bezpieczeństwa. Pamiętaj, że IT to niezwykle dynamiczna branża.
Aktualizuj swoje pakiety zależne
W razie wykrycia luk, autorzy paczek starają się je łatać. Aby Twoja aplikacja korzystała z tych zmian (fiksów), musisz je w razie potrzeby aktualizować. Niestety często posiadamy ich tak dużo, że ręcznie sprawdzanie każdej z nich byłoby dość karkołomnym zadaniem. Dobrym wyjściem może więc być skorzystanie z narzędzia o nazwie Snyk. Automatycznie sprawdza ono repozytorium GH i raportuje na temat ewentualnych niebezpieczeństw. Możesz z niego skorzystać online pod tym linkiem.
Istnieje też paczka snyk do terminala, która pozwala na sprawdzanie Twoich projektów lokalnie.
Ćwiczenie
Pobierz snyk globalnie:
yarn add snyk@1.235.0 -g
W ten sposób paczka będzie mogła być wykorzystywana w różnych projektach – wtedy, kiedy zapragniemy sprawdzić, jak wygląda sytuacja z naszymi pakietami.
Aby jednak korzystać z niej lokalnie, musisz jeszcze założyć konto w serwisie snyk.io.
Następnie wejdź do folderu ze swoim projektem strony festiwalu muzycznego i uruchom polecenie:
snyk auth
Pozwoli Ci to zalogować się do swojego konta.
Następnie wpisz komendę:
snyk test
Sprawdzi ona wszystkie pakiety zależne i odpowiednio zaaportuje Ci rezultat testów.
Znajdź i napraw
Oprócz komendy snyk test, w paczce istnieje również snyk wizard, dzięki której skorzystamy z bardziej zaawansowanej wersji narzędzia. Nie tylko wskaże ona ewentualne luki w bezpieczeństwie, ale pozwoli też na ich automatyczne rozwiązanie.
Korzystaj z paczki Helmet
Oprócz powyższych dobrych praktyk standardem jest użycie paczki helmet. Jej zadanie polega na odpowiednim ustawieniu nagłówków HTTP, tak aby nasz serwer był mniej podatny na ataki. Opis wszystkich funkcjonalności znajdziesz na stronie https://helmetjs.github.io/. Potraktuj tę paczkę jako podstawę i coś, co w miarę możliwości utrudni ataki, które mogłyby wykorzystywać słabości ustawień nagłówków.
Uwaga!
Często programiści, zwłaszcza początkujący, traktują helmet() jako coś, co w pojedynkę obroni naszą aplikację. Nie do końca tak jest. To narzędzie, od którego warto zacząć, ale nie wystarczy, aby aplikacja była bezpieczna.
Przede wszystkim musimy zadbać o dobrze napisany, odporny kod, czego tyczyły się wcześniejszy podrozdziały. Pamiętaj o tym!
Ćwiczenie
W ramach ćwiczenie postaraj się wykorzystać helmet w naszej aplikacji festiwalu. Jego użycie jest bardzo proste.
Wystarczy pobrać tę paczkę:
yarn add helmet@3.21.1
Następnie zaimportować i użyć jako middleware:
const helmet = require('helmet');
...
app.use(helmet());
Podsumowanie
Czy podążanie za dobrymi praktykami z tego submodułu wystarczy? Niestety nie zawsze. Im bardziej rozbudowana i skomplikowana aplikacja, tym więcej luk może się w niej pojawić. Jeśli jej rola jest newralgiczna (np. tworzymy stronę banku), będziemy narażeni na więcej ataków, a te mogą być często niezwykle wyrafinowane. Nie jesteśmy więc w stanie przygotować się na zagrożenie w stu procentach. Nawet jeśli wydaje nam się, że wzięliśmy pod uwagę wszystko, może okazać się, że atakujący wymyśli jakiś nowy sposób, np. złamie używany przez nasz szyfr kodowania.
Mimo to musimy starać się maksymalnie zniwelować niebezpieczeństwo. Zabezpieczanie aplikacji to bardzo ważna sprawa, a branża ciągle się rozwija. Ważne systemy muszą być na bieżąco analizowane i w razie potrzeby korygowane, aby radzić sobie z nowymi formami ataków. Dopiero takie podejście daje nam relatywnie dużą gwarancję co do bezpieczeństwa.